昨天的四個指標衡量的是「通用」的 RAG 品質,但每個系統都有自己在意的事。我們的技術問答助理至少有三個需求,是 Faithfulness 與 Context Recall 看不出來的:答案要用繁體中文、知識庫沒有的問題要拒答、比較題要把兩邊都講到。今天就把這三個需求變成可量化的自訂指標。
AspectCritic 做一個是非判斷型指標RubricsScore 做一個分級評分型指標| 業務需求 | 指標 | 做法 |
|---|---|---|
| 回答要用繁體中文 | traditional_chinese |
規則型 |
| 知識庫沒有的問題要拒答 | refused,再算出「正確拒答率」與「誤拒率」 |
規則型 |
| 比較題要兩邊都講到 | comparison_complete |
AspectCritic |
| 回答要完整涵蓋重點 | completeness |
RubricsScore |
要量測拒答能力,需要一批「知識庫裡沒有答案」的問題。新增 eval/testset_oos.json,oos 是 out-of-scope 的縮寫:
[
{"id": 101, "question": "今天台中的天氣如何?"},
{"id": 102, "question": "Kubernetes 的 Pod 是什麼?"},
{"id": 103, "question": "Python 的 GIL 是什麼?"},
{"id": 104, "question": "OAuth 2.0 的授權碼流程是什麼?"},
{"id": 105, "question": "Redis 為什麼這麼快?"}
]
這些題目都沒有 reference,因為「正確答案」本來就是拒答。請確認這些主題真的不在你的 data/ 文件裡,否則系統照實回答反而是對的。
另外要注意,Kubernetes、GIL 這類題目是 qwen3:8b 自己「懂」的內容。這正是考驗重點:模型有能力回答,但 prompt 要求它只能根據資料,看它守不守規矩。
新建 custom_metrics.py。RAGAS 提供 discrete_metric 裝飾器,讓你把一個普通函式包裝成指標:
from ragas.metrics import AspectCritic, RubricsScore, discrete_metric
from ragas.metrics.result import MetricResult
# 常見的「簡體專用字」(繁體寫法不同,所以出現就代表用了簡體)
SIMPLIFIED_CHARS = set(
"们这为个说时来学国对会过还发实现开关种应经与机动样请问题"
"无数据软储库档户统网络码认证权错误资处并将从该让则条约设计读写类"
)
REFUSAL_KEYWORD = "無法回答"
@discrete_metric(name="traditional_chinese", allowed_values=["pass", "fail"])
def traditional_chinese(response: str) -> MetricResult:
found = sorted({c for c in response if c in SIMPLIFIED_CHARS})
if found:
return MetricResult(value="fail", reason=f"出現簡體字:{''.join(found)}")
return MetricResult(value="pass", reason="未發現簡體字")
@discrete_metric(name="refused", allowed_values=["yes", "no"])
def refused(response: str) -> MetricResult:
if REFUSAL_KEYWORD in response:
return MetricResult(value="yes", reason="回答中包含拒答用語")
return MetricResult(value="no", reason="回答中沒有拒答用語")
拆解這段程式:
| 片段 | 說明 |
|---|---|
@discrete_metric(name=..., allowed_values=[...]) |
宣告這是一個「離散值」指標,輸出只能是 allowed_values 裡的其中一個 |
函式的參數 response |
參數名稱就是這個指標需要的資料,呼叫時用同名參數傳入 |
MetricResult(value=..., reason=...) |
回傳結果與原因,reason 在除錯時很有用 |
| 使用方式 | traditional_chinese.score(response="...") |
兩個規則的誠實限制:
在同一個檔案加入兩個由評審模型判斷的指標:
comparison_complete = AspectCritic(
name="comparison_complete",
definition=(
"問題是在比較兩個事物的差異。回答是否同時說明了兩邊各自的特點?"
"如果回答只說明其中一邊,或沒有做出比較,請判定為不符合。"
),
)
completeness = RubricsScore(
name="completeness",
rubrics={
"score1_description": "回答與標準答案幾乎無關,或完全沒有回答問題。",
"score2_description": "回答只涵蓋標準答案的一小部分,遺漏大部分重點。",
"score3_description": "回答涵蓋標準答案約一半的重點,仍有明顯遺漏。",
"score4_description": "回答涵蓋標準答案大部分重點,僅遺漏少數細節。",
"score5_description": "回答完整涵蓋標準答案的所有重點,沒有遺漏。",
},
)
AspectCritic 只需要「名稱」與「一句定義」。評審模型讀完定義後,對每筆資料判斷符不符合,輸出 1 或 0。寫定義有幾個訣竅:
RubricsScore 則要你替每個分數寫出描述(score1_description 到 score5_description)。評審模型會對照這份量表選出最符合的分數。量表的每一級要互相有明顯區別,如果 3 分和 4 分的描述聽起來差不多,評審模型的判斷就會搖擺。我們的量表以「涵蓋標準答案多少重點」為軸,所以評分時會參考測試集的 reference。
新建 evaluate_custom.py,這支程式會重用昨天的成果:讀取昨天存下來的 eval/rag_outputs.json(避免重跑 20 題生成),再把「知識庫外」的 5 題跑一遍。
import json
from pathlib import Path
import pandas as pd
from ragas import EvaluationDataset, evaluate
from ragas.dataset_schema import SingleTurnSample
from ragas.run_config import RunConfig
from build_rag import build_rag_chain, get_vectorstore
from custom_metrics import comparison_complete, completeness, refused, traditional_chinese
from evaluate_rag import EVAL_DIR, OUTPUTS_PATH, get_judge
OOS_PATH = EVAL_DIR / "testset_oos.json"
def answer_questions(chain, questions):
rows = []
for item in questions:
result = chain.invoke(item["question"])
rows.append({"question": item["question"], "answer": result["answer"]})
print(f"[知識庫外] {item['question']} → {result['answer'][:40]}")
return rows
規則型指標直接逐筆呼叫 .score(),整理成表格:
def run_rule_metrics(in_scope, out_of_scope):
records = []
for group, rows in [("知識庫內", in_scope), ("知識庫外", out_of_scope)]:
for r in rows:
tc = traditional_chinese.score(response=r["answer"])
rf = refused.score(response=r["answer"])
records.append({
"group": group,
"question": r["question"],
"traditional_chinese": tc.value,
"tc_reason": tc.reason,
"refused": rf.value,
})
return pd.DataFrame(records)
評審型指標沿用昨天的 evaluate() 與評審模型設定:
def run_judge_metric(rows, metric):
if not rows:
return pd.DataFrame()
dataset = EvaluationDataset(samples=[
SingleTurnSample(user_input=r["question"], response=r["answer"], reference=r["reference"])
for r in rows
])
llm, embeddings = get_judge()
result = evaluate(
dataset=dataset,
metrics=[metric],
llm=llm,
embeddings=embeddings,
run_config=RunConfig(timeout=600, max_workers=2),
raise_exceptions=False,
)
return result.to_pandas()
接著是報表與主程式:
def report(rule_df, comparison_df, completeness_df):
inside = rule_df[rule_df["group"] == "知識庫內"]
outside = rule_df[rule_df["group"] == "知識庫外"]
print("\n===== 自訂指標(規則型)=====")
print(f"繁體中文合規率:{(rule_df['traditional_chinese'] == 'pass').mean():.0%}(共 {len(rule_df)} 題)")
print(f"誤拒率(知識庫內卻拒答):{(inside['refused'] == 'yes').mean():.0%}(共 {len(inside)} 題)")
print(f"正確拒答率(知識庫外有拒答):{(outside['refused'] == 'yes').mean():.0%}(共 {len(outside)} 題)")
print("\n===== 自訂指標(評審型)=====")
if not comparison_df.empty:
print(f"比較題兩邊兼顧:{comparison_df['comparison_complete'].mean():.2f}"
f"(共 {len(comparison_df)} 題,NaN {comparison_df['comparison_complete'].isna().sum()} 題)")
print(f"回答完整度(1–5 分):{completeness_df['completeness'].mean():.2f}"
f"(共 {len(completeness_df)} 題,NaN {completeness_df['completeness'].isna().sum()} 題)")
if __name__ == "__main__":
in_scope = json.loads(OUTPUTS_PATH.read_text(encoding="utf-8"))
chain = build_rag_chain(get_vectorstore())
out_of_scope = answer_questions(chain, json.loads(OOS_PATH.read_text(encoding="utf-8")))
rule_df = run_rule_metrics(in_scope, out_of_scope)
comparison_df = run_judge_metric([r for r in in_scope if r["type"] == "比較"], comparison_complete)
completeness_df = run_judge_metric(in_scope, completeness)
rule_df.to_csv(EVAL_DIR / "custom_rule_results.csv", index=False, encoding="utf-8-sig")
comparison_df.to_csv(EVAL_DIR / "custom_comparison_results.csv", index=False, encoding="utf-8-sig")
completeness_df.to_csv(EVAL_DIR / "custom_completeness_results.csv", index=False, encoding="utf-8-sig")
report(rule_df, comparison_df, completeness_df)
幾個設計重點:
comparison_complete 只套用在「比較」題(type == "比較",共 4 題)。對「ACID 有哪四個特性」這種題目問「有沒有比較兩邊」沒有意義,會產生誤導性的低分。指標要用在它適用的資料上,這也是昨天在測試集裡加 type 欄位的原因。LIMIT = None 跑完 20 題,這支程式才會讀到完整的 rag_outputs.json。如果昨天只跑了 3 題,請先把 LIMIT 改成 None 重跑一次。執行:
uv run python evaluate_custom.py
規則型指標瞬間就會算完,評審型指標則需要等本地模型。不過這次的呼叫量比昨天少很多:比較題 4 題、完整度 20 題,每題大約各一次模型呼叫。
| 指標 | 理想狀態 | 如果偏低,優先檢查 |
|---|---|---|
| 繁體中文合規率 | 接近 100% | 打開 custom_rule_results.csv 看 tc_reason,確認是哪些簡體字;考慮在 prompt 強調繁體中文,或換模型 |
| 正確拒答率 | 接近 100% | 檢查 prompt 的拒答指令;看模型是否「忍不住」用自己的知識回答 |
| 誤拒率 | 接近 0% | 通常是檢索沒撈到該撈的內容,模型因此拒答,回頭看 Context Recall |
| 比較題兩邊兼顧 | 越接近 1 越好 | 檢索可能只撈到比較的其中一邊,考慮調高 k |
| 回答完整度 | 越高越好 | 對照 Context Recall:如果兩者都低,是沒撈到;只有完整度低,是模型回答得太簡略 |
最後再提醒一次評審型指標的老問題:NaN 要先看,本地 8B 模型的判斷有雜訊,分數適合拿來比較前後版本,不適合當絕對標準。規則型指標則沒有這個問題,這也是它們值得優先寫的原因。
今天為系統量身打造了四個指標,明天會用這些指標做錯誤分析與調參,挑出低分的題目、判斷問題出在檢索還是生成,調整 chunk_size 與 k,再與 baseline 比較,驗證改動到底有沒有用。